Skip to content

trimooo/react-ai-stream

Repository files navigation

react-ai-stream

CI npm npm npm npm downloads bundle size License: MIT Live demo

Backend-agnostic AI streaming for React. One hook. Any provider. Drop-in UI or bring your own.

  • One hookuseAIChat manages messages, loading state, abort, and errors
  • Any backend — Anthropic, OpenAI, Groq, or your own streaming endpoint
  • Optional UI — drop-in <Chat /> component or wire the hook to your own design
  • Event hooksonToken, onComplete, onError for side-effects without extra state
  • TypeScript first — strict types, full DTS, ESM + CJS

Who this is for

SaaS teams adding AI chat to an existing product — you already have a design system, you don't want to be locked into ours.

Enterprise dashboards that need multiple isolated chat instances on the same page — model comparisons, parallel streams, side-by-side analysis.

AI copilot builders who want streaming primitives, not opinions. Wire useAIChat to any interface: a sidebar, a floating widget, a full-page chat.

Teams with a Python, Go, or Rails backend — the hook speaks plain HTTP + SSE. Your existing server can produce the stream. No Node.js required.

Not for: agent systems, RAG, orchestration, or memory platforms. This library stays focused on streaming AI responses into React.

// 1. npm install @react-ai-stream/react @react-ai-stream/ui
// 2. Add an API route (see Backend Setup below)
// 3. Done.

'use client'
import { useAIChat } from '@react-ai-stream/react'
import { Chat } from '@react-ai-stream/ui'
import '@react-ai-stream/ui/styles'

export default function Page() {
  const { messages, sendMessage, loading, stop } = useAIChat({
    endpoint: '/api/chat',
  })

  return (
    <div style={{ height: '80vh' }}>
      <Chat messages={messages} onSend={sendMessage} onStop={stop} loading={loading} />
    </div>
  )
}

Architecture

flowchart LR
    subgraph App["React Application"]
        direction TB
        UI["Your UI / &lt;Chat /&gt;"]
        Hook["useAIChat()"]
        Store["Zustand store\nmessages · loading · error"]
        Client["SSE parser\n+ normalizer"]
        UI <-->|"messages · stop"| Hook
        Hook <--> Store
        Hook --> Client
    end

    subgraph Server["Your Server  (Next.js · Express · FastAPI · Go)"]
        Route["/api/chat"]
    end

    subgraph Providers["LLM Providers"]
        P["Anthropic · OpenAI\nGroq · Custom · Local"]
    end

    Client -->|"POST messages"| Route
    Route <-->|"provider API"| Providers
    Route -->|"SSE stream\ndata: {type:'text'}\ndata: {type:'done'}"| Client
    Store -->|"useSyncExternalStore"| UI
Loading

Your React app never knows which LLM produced the stream. The hook speaks a three-event protocol (text, done, error) over SSE. Any server that produces those events works.


Why not Vercel AI SDK?

react-ai-stream Vercel AI SDK
Bundle size ~12 kB ~90 kB+
Framework lock-in None — plain React Next.js / Vercel optimized
Backend required Optional (direct providers work) Yes for most features
Custom endpoint First-class Via adapters
Pre-built UI Yes (@react-ai-stream/ui) No
Event hooks onToken / onComplete / onError Limited
License MIT MIT

react-ai-stream is a good fit when you want a small, portable library with no framework opinions. If you're all-in on Next.js and need RSC streaming or server actions, Vercel AI SDK is worth evaluating.


Installation

npm install @react-ai-stream/react @react-ai-stream/ui
# or
pnpm add @react-ai-stream/react @react-ai-stream/ui

Peer dependencies: React 18 or 19.


Backend Setup

Next.js App Router

// app/api/chat/route.ts
import { NextRequest } from 'next/server'

export const runtime = 'edge'

export async function POST(req: NextRequest) {
  const { messages } = await req.json()

  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.ANTHROPIC_API_KEY!,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      messages,
      stream: true,
    }),
  })

  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder()
      const send = (data: object) =>
        controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`))

      const reader = response.body!.getReader()
      const decoder = new TextDecoder()
      let buf = ''
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        buf += decoder.decode(value, { stream: true })
        const parts = buf.split('\n\n')
        buf = parts.pop() ?? ''
        for (const part of parts) {
          for (const line of part.split('\n')) {
            if (!line.startsWith('data: ')) continue
            try {
              const ev = JSON.parse(line.slice(6))
              if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta')
                send({ type: 'text', text: ev.delta.text })
              else if (ev.type === 'message_stop')
                send({ type: 'done' })
            } catch { /* skip */ }
          }
        }
      }
      send({ type: 'done' })
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  })
}

The SDK expects your endpoint to emit SSE lines in this format:

Chunk Meaning
{ "type": "text", "text": "..." } Append text to the assistant message
{ "type": "done" } Stream is complete
{ "type": "error", "error": "..." } Surface an error

Express / Node.js

import express from 'express'
import Anthropic from '@anthropic-ai/sdk'

const app = express()
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  const { messages } = req.body
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')

  const send = (data: object) => res.write(`data: ${JSON.stringify(data)}\n\n`)

  const client = new Anthropic()
  const stream = await client.messages.stream({
    model: 'claude-sonnet-4-6',
    max_tokens: 1024,
    messages,
  })

  for await (const event of stream) {
    if (event.type === 'content_block_delta' && event.delta.type === 'text_delta')
      send({ type: 'text', text: event.delta.text })
  }

  send({ type: 'done' })
  res.end()
})

useAIChat Hook

const {
  messages,      // Message[]  — full conversation history
  sendMessage,   // (text: string) => Promise<void>
  loading,       // boolean    — true while streaming
  stop,          // () => void — abort in-flight stream
  error,         // string | null
  clearMessages, // () => void — reset conversation
} = useAIChat(options)

Options

Option Type Description
endpoint string URL of your streaming API route
headers Record<string, string> Extra headers sent with every request
body Record<string, unknown> Extra fields merged into every request body
provider 'openai' | 'anthropic' Direct provider (no backend needed)
apiKey string API key for direct provider
model string Model name
baseURL string Override base URL (OpenAI-compatible APIs)
maxTokens number Max tokens (Anthropic only)
system string System prompt (direct providers only)
client AIClient Bring your own pre-built client
onToken (token: string) => void Called for each streamed text chunk
onComplete (message: Message) => void Called when the full response is done
onError (error: Error) => void Called on stream or provider errors

Event hooks example

const chat = useAIChat({
  endpoint: '/api/chat',
  onToken: (token) => {
    // e.g. update a word count in real-time
    setTokenCount((n) => n + 1)
  },
  onComplete: (message) => {
    // e.g. save the final response to a database
    saveToHistory(message)
  },
  onError: (err) => {
    // e.g. report to Sentry
    Sentry.captureException(err)
  },
})

Message shape

interface Message {
  id: string
  role: 'user' | 'assistant' | 'system' | 'tool'
  content: string
  createdAt: Date
}

Providers

Custom endpoint (recommended)

const chat = useAIChat({ endpoint: '/api/chat' })

// With extra headers or body fields:
const chat = useAIChat({
  endpoint: '/api/chat',
  headers: { 'X-Session-Id': sessionId },
  body: { persona: 'support-agent' },
})

Anthropic direct

const chat = useAIChat({
  provider: 'anthropic',
  apiKey: process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY!,
  model: 'claude-sonnet-4-6',
  maxTokens: 2048,
  system: 'You are a helpful assistant.',
})

OpenAI direct

const chat = useAIChat({
  provider: 'openai',
  apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY!,
  model: 'gpt-4o',
  system: 'You are a helpful assistant.',
})

Groq (OpenAI-compatible)

const chat = useAIChat({
  provider: 'openai',
  apiKey: process.env.NEXT_PUBLIC_GROQ_API_KEY!,
  baseURL: 'https://api.groq.com/openai/v1',
  model: 'llama-3.3-70b-versatile',
})

Pre-built UI

<Chat /> — all-in-one

import { Chat } from '@react-ai-stream/ui'
import '@react-ai-stream/ui/styles'

<Chat
  messages={messages}
  onSend={sendMessage}
  onStop={stop}
  loading={loading}
  placeholder="Type here…"
  className="my-chat"
/>

Individual components

import { MessageList, ChatInput, MarkdownRenderer } from '@react-ai-stream/ui'

function MyChatUI() {
  const { messages, sendMessage, loading, stop } = useAIChat({ endpoint: '/api/chat' })
  return (
    <div>
      <MessageList messages={messages} loading={loading} />
      <ChatInput onSend={sendMessage} onStop={stop} loading={loading} />
    </div>
  )
}

Theming with CSS variables

:root {
  --ras-bg: #0f172a;
  --ras-bg-user: #6366f1;
  --ras-bg-assistant: #1e293b;
  --ras-text: #f1f5f9;
  --ras-text-user: #ffffff;
  --ras-text-muted: #94a3b8;
  --ras-border: #334155;
  --ras-radius: 16px;
  --ras-font: 'Inter', sans-serif;
  --ras-code-bg: #0d1117;
  --ras-code-text: #c9d1d9;
}
Variable Default Controls
--ras-bg #ffffff Chat container background
--ras-bg-user #2563eb User message bubble
--ras-bg-assistant #f3f4f6 Assistant message bubble
--ras-text #111827 Base text color
--ras-text-user #ffffff Text inside user bubbles
--ras-text-muted #6b7280 Typing indicator, timestamps
--ras-border #e5e7eb Input border, dividers
--ras-radius 12px Bubble corner radius
--ras-font system-ui, sans-serif Font family
--ras-code-bg #1e293b Code block background
--ras-code-text #e2e8f0 Code block text

Dark mode

@media (prefers-color-scheme: dark) {
  :root {
    --ras-bg: #0f172a;
    --ras-bg-user: #6366f1;
    --ras-bg-assistant: #1e293b;
    --ras-text: #f1f5f9;
    --ras-text-muted: #94a3b8;
    --ras-border: #334155;
    --ras-code-bg: #0d1117;
    --ras-code-text: #c9d1d9;
  }
}

Customization Recipes

Custom UI (bypass <Chat />)

'use client'
import { useState } from 'react'
import { useAIChat } from '@react-ai-stream/react'
import { MarkdownRenderer } from '@react-ai-stream/ui'
import '@react-ai-stream/ui/styles'

export function SupportWidget() {
  const { messages, sendMessage, loading, stop } = useAIChat({ endpoint: '/api/chat' })
  const [input, setInput] = useState('')

  return (
    <div className="widget">
      <div className="messages">
        {messages.map((m) => (
          <div key={m.id} className={`bubble bubble--${m.role}`}>
            {m.role === 'assistant'
              ? <MarkdownRenderer content={m.content} />
              : <p>{m.content}</p>}
          </div>
        ))}
      </div>
      <form onSubmit={(e) => { e.preventDefault(); sendMessage(input); setInput('') }}>
        <input value={input} onChange={(e) => setInput(e.target.value)} disabled={loading} />
        {loading
          ? <button type="button" onClick={stop}>Stop</button>
          : <button type="submit">Send</button>}
      </form>
    </div>
  )
}

System prompt (server-side, recommended)

// In your route handler — keeps the prompt private
body: JSON.stringify({
  model: 'claude-sonnet-4-6',
  system: 'You are a friendly support agent for Acme Inc.',
  messages,
  stream: true,
})

Multiple independent chats

Each useAIChat call has a completely isolated store — no context needed:

const claude = useAIChat({ endpoint: '/api/chat?model=claude' })
const gpt    = useAIChat({ endpoint: '/api/chat?model=gpt' })

// Broadcast the same message to both
function sendToAll(text: string) {
  claude.sendMessage(text)
  gpt.sendMessage(text)
}

Shared client via context

import { createAIClient } from '@react-ai-stream/core'
import { AIChatProvider, useAIChat } from '@react-ai-stream/react'

const client = createAIClient({ endpoint: '/api/chat' })

function App() {
  return (
    <AIChatProvider client={client}>
      <MainChat />
    </AIChatProvider>
  )
}

function MainChat() {
  const { messages, sendMessage, loading } = useAIChat({} as any)
  // ...
}

Programmatic control

const { sendMessage, clearMessages, stop, messages } = useAIChat({ endpoint: '/api/chat' })

// Send on mount (e.g., welcome message)
useEffect(() => { sendMessage('Say hello briefly.') }, [])

// Reset
function newChat() {
  stop()
  clearMessages()
}

// Read last assistant reply
const lastReply = messages.findLast((m) => m.role === 'assistant')?.content

API Reference

useAIChat(options)

Returns UseAIChatReturn:

interface UseAIChatReturn {
  messages:      Message[]
  sendMessage:   (content: string) => Promise<void>
  loading:       boolean
  stop:          () => void
  error:         string | null
  clearMessages: () => void
}

<Chat />

Prop Type Required Description
messages Message[] yes Message array from useAIChat
onSend (text: string) => void yes Called when user submits
onStop () => void no Abort handler — shows Stop button
loading boolean no Enables typing indicator
placeholder string no Input placeholder text
className string no Extra CSS class on root div

<MessageList />

Prop Type Description
messages Message[] Messages to render
loading boolean Show animated typing indicator
className string Extra CSS class

<ChatInput />

Prop Type Description
onSend (text: string) => void Submit handler
onStop () => void Abort handler
loading boolean Disable input, show Stop button
placeholder string Textarea placeholder
disabled boolean Hard-disable the input

<MarkdownRenderer />

Prop Type Description
content string Markdown string to render
className string Extra CSS class on wrapper

Renders GitHub-Flavored Markdown with syntax-highlighted code blocks and a copy button on each.

createAIClient(options)

import { createAIClient } from '@react-ai-stream/core'

const client = createAIClient({ endpoint: '/api/chat' })
const client = createAIClient({ provider: 'openai', apiKey: '...', model: 'gpt-4o' })
const client = createAIClient({ provider: 'anthropic', apiKey: '...', model: 'claude-sonnet-4-6' })

Returns AIClient — pass to useAIChat({ client }) or <AIChatProvider client={...}>.


Community

Built with react-ai-stream

Using react-ai-stream in a project? Open a discussion or PR this table.

Project Description
Live demo 3-model parallel streaming — Groq × Llama 3.3, Llama 3.1, Llama 4 Scout

License

MIT

About

Backend-agnostic AI streaming for React — useAIChat hook, built-in providers, drop-in UI

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors